<?php
/**
 * dbfront
 * (c) 2010-2013 Stephen Adkins <spadkins@gmail.com>
 * This software began development in 2010.
 * It is based on code dating from 1999.
 *
 * License: GPL 3.0 (or later)
 * The contents of this file may be used under the terms of
 * the GNU General Public License Version 2 or later (the "GPL").
 *
 * Software distributed under the License is distributed on an "AS IS" basis,
 * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
 * for the specific language governing rights and limitations under the
 * License.
 */
namespace App;
/**
 * App\Authentication
 *
 * This is a pluggable authentication service that provides facilities for
 * looking up a user and authenticating a user.
 *
 * @copyright Copyright 2013 Internet Dynamics, http://www.internetdynamics.com
 * @license   GPLv3 (or later) (users may license this software commercially from Internet Dynamics if they need to use it under a license which does not have copyleft)
 * @author    Stephen Adkins <spadkins@internetdynamics.com>
 * @package   sitefront/front-framework
 * @uses      App\Context, App
 * @version   $Id$
 */
class Authentication extends Service {
    protected $test_mode, $client_connection_token;

    /**
     * getClientConnectionToken()
     * @return string $client_connection_token
     */
    public function getClientConnectionToken () {
        global $trace;
        if ($trace) trace_entry();
        if (isset($this->client_connection_token)) {
            $client_connection_token = $this->client_connection_token;
        }
        else {
            $server_secret = $this->context->getOption('server_secret','398F0187133094C3188BCF4');
            $clientConnectionData = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : 'UNKNOWN';
            $matches = array();
            foreach (array('HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_X_CLUSTER_CLIENT_IP', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED') as $key) {
                if (array_key_exists($key, $_SERVER) === true) {
                    if (preg_match('/^ *([ ,]+)/', $key, $matches))   # only get the first non-blank string (source address, not the intervening forwarders)
                        $clientConnectionData .= ';' . $matches[1];
                }
            }
            $clientConnectionData .= '|' . (isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : 'Unknown User-Agent');
            $clientConnectionData .= '|' . $server_secret;
            $client_connection_token = hash('sha256' , $clientConnectionData);  # hash it into a token
            $this->client_connection_token = $client_connection_token;
        }
        if ($trace) trace_exit($client_connection_token);
        return($client_connection_token);
    }

    /**
     * getUserAuthenticationSpecs ($request, &$response)
     * @param array $request
     * @param array &$response
     * @return void
     */
    public function getUserAuthenticationSpecs ($request, &$response) {
        global $trace, $options;
        if ($trace) trace_entry();
        if (!isset($request['client_connection_token']) || !$request['client_connection_token']) throw new \Exception('No client_connection_token provided',ERROR_AUTH_NOCONNTOKEN);
        $client_connection_token = $request['client_connection_token'];
        if ($client_connection_token != $this->getClientConnectionToken()) throw new \Exception('Current client_connection_token does not match original',ERROR_AUTH_BADCONNTOKEN);

        # if there are captcha fields, we should create the user record (including random salt).
        $createUser = false;
        if (array_key_exists('recaptcha_challenge_field', $request) ||
            array_key_exists('recaptcha_response_field', $request)) {
            $this->validateCaptcha($request, $response);
            $createUser = true;
        }
        # TODO: should allow only when signup_mode is relaxed or in test_mode (otherwise, we require a captcha) (I think ...)
        else if (array_key_exists('create', $request)) {
            $createUser = true;
        }

        $authdb = $this->context->authdb();

        $user_token = null;
        $email = null;
        $username = null;
        $cell_phone = null;

        if (isset($request['user_token'])) {
            $user_token = $request['user_token'];
            if     (preg_match('/@/', $user_token))        { $email      = $user_token; $request['email']      = $email; }
            elseif (preg_match('/^[0-9]+$/', $user_token)) { $cell_phone = $user_token; $request['cell_phone'] = $cell_phone; }
            else                                           { $username   = $user_token; $request['username']   = $username; }
        }
        if (!isset($email)      && isset($request['email']))      { $email      = $request['email']; }
        if (!isset($username)   && isset($request['username']))   { $username   = $request['username']; }
        if (!isset($cell_phone) && isset($request['cell_phone'])) { $cell_phone = $request['cell_phone']; }

        if (isset($email)) {
            list($user_id, $user_salt, $algorithm, $algorithm_option) = $authdb->get_row('select user_id, salt, algorithm, algorithm_option from {auth_schema_}auth_user where email = ?', $email);
        }
        elseif (isset($username)) {
            list($user_id, $user_salt, $algorithm, $algorithm_option) = $authdb->get_row('select user_id, salt, algorithm, algorithm_option from {auth_schema_}auth_user where username = ?', $username);
        }
        elseif (isset($cell_phone)) {
            list($user_id, $user_salt, $algorithm, $algorithm_option) = $authdb->get_row('select user_id, salt, algorithm, algorithm_option from {auth_schema_}auth_user where cell_phone = ?', $cell_phone);
        }
        else {
            throw new \Exception('No user identifer supplied (user_token, email, username, cell_phone)',ERROR_AUTH_NOUSERTOKEN);
        }
        if (!isset($email) && !isset($cell_phone)) throw new \Exception('No contact method (email or cell_phone) provided',ERROR_AUTH_NOUSERTOKEN);

        if ($user_id) {
            $response['user_id']          = $user_id;
            $response['user_salt']        = $user_salt;
            $response['algorithm']        = $algorithm;
            $response['algorithm_option'] = $algorithm_option;
        }
        elseif ($createUser) {
            $this->createUser($request, $response, 0);
        }
        else {
            $response['success'] = false;
            $response['message'] = 'User not found';
            #if (!$username && $email) $username = $email;
            #throw new \Exception("User [$username] Not Found",ERROR_AUTH_USERNOTFOUND);
        }
        if ($trace) trace_exit();
    }

    /**
     * validateCaptcha ($request, &$response)
     * @param array $request
     * @param array &$response
     * @return void
     */
    public function validateCaptcha ($request, &$response) {
        global $trace;
        if ($trace) trace_entry();

        global $options;
        if (!isset($options['recaptcha_auto_validate']) || !$options['recaptcha_auto_validate']) {
            if (!isset($request['recaptcha_challenge_field']) || !$request['recaptcha_challenge_field']) throw new \Exception('No Captcha Challenge',ERROR_AUTH_NOCAPTCHACHALLENGE);
            $recaptcha_challenge_field = $request['recaptcha_challenge_field'];
            if (!isset($request['recaptcha_response_field']) || !$request['recaptcha_response_field']) throw new \Exception('No Captcha Response',ERROR_AUTH_NOCAPTCHARESPONSE);
            $recaptcha_response_field = $request['recaptcha_response_field'];

            require_once('recaptchalib.php');
            $recaptcha_private_key = $options['recaptcha_private_key'];
            $resp = recaptcha_check_answer($recaptcha_private_key, $_SERVER['REMOTE_ADDR'], $recaptcha_challenge_field, $recaptcha_response_field);
            if (!$resp->is_valid) {
                throw new \Exception('Bad Captcha Response',ERROR_AUTH_BADCAPTCHARESPONSE);
            }
        }
        $response['success'] = true;

        if ($trace) trace_exit();
    }

    /**
     * createUser ($request, &$response)
     * @param array $request
     * @param array &$response
     * @param integer $auth_tables = 1
     * @return void
     */
    public function createUser ($request, &$response, $aux_tables = 1) {
        global $trace, $options;
        if ($trace) trace_entry();

        $email = null;
        $username = null;
        $cell_phone = null;

        if (!isset($email)      && isset($request['email']))      { $email      = $request['email']; }
        if (!isset($username)   && isset($request['username']))   { $username   = $request['username']; }
        if (!isset($cell_phone) && isset($request['cell_phone'])) { $cell_phone = $request['cell_phone']; }
        $accept_terms_ind       = (isset($request['accept_terms_ind']))       ? $request['accept_terms_ind']       : null;
        $opt_in_email_ind       = (isset($request['opt_in_email_ind']))       ? $request['opt_in_email_ind']       : null;

        if (isset($username) && $username) {
            $username = $this->createUniqueUsername($username);
            if (!isset($email) || !$email) {
                $email = $this->createUniqueEmail($username);
            }
        }
        else if (isset($email) && $email) {
            $username = $this->createUniqueUsername(null, $email);
        }
        else if (isset($cell_phone) && $cell_phone) {
            $username          = 'c' . $cell_phone;
        }
        else {
            throw new \Exception('No user token provided to create Authentication Specs',ERROR_AUTH_NOUSERTOKEN);
        }

        $cryptTool             = new \CryptTool();
        $randomBytes           = $cryptTool->get_random_bytes(48);
        $salt                  = $cryptTool->base64_encode_salt($randomBytes, 64);
        $algorithm             = 'SHA256xN';
        #$algorithm_option      = 16384;
        $algorithm_option      = 1024;
        if (isset($request['password']) && $request['password'] && isset($options['test_mode']) && $options['test_mode']) {
            $password   = $request['password'];
            $password_hash = $this->createPasswordHash($algorithm, $algorithm_option, $password, $salt);
        }
        else {
            $password_hash = 'X';
        }
        $authdb = $this->context->authdb();
        $authdb->begin_work();
        try {
            $authdb->execute('insert into {auth_schema_}auth_owner (owner_name, owner_type) values (?, ?)', array($username,'P'));
            $owner_id = $authdb->last_insert_id('auth_owner');

            $authdb->execute("insert into {auth_schema_}auth_user (user_id, username, email, cell_phone, password_hash, salt, algorithm, algorithm_option, status, accept_terms_ind, opt_in_email_ind, unused_dttm) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, {current_datetime_utc})",
                             array($owner_id, $username, $email, $cell_phone, $password_hash, $salt, $algorithm, $algorithm_option, 'U', $accept_terms_ind, $opt_in_email_ind));
            if ($aux_tables) {
                $authdb->execute('insert into {auth_schema_}auth_group (group_id, group_name, group_long_name, owner_user_id) values (?, ?, ?, ?)', array($owner_id, $username, $username, $owner_id));
                $authdb->execute('insert into {auth_schema_}auth_group_user_memb (group_id, user_id) values (?, ?)', array($owner_id, $owner_id));
                # we shouldn't have to do this because it is a public group
                #$authdb->execute('insert into {auth_schema_}auth_group_user_memb (group_id, user_id) values (?, ?)', array(APP_ALLUSERS_GROUP_ID, $owner_id));  # every user is a member of 'all-users'
            }
            $authdb->commit();

            $response['user_id']          = $owner_id;
            $response['user_salt']        = $salt;
            $response['algorithm']        = $algorithm;
            $response['algorithm_option'] = $algorithm_option;
        }
        catch (Exception $e) {
            $authdb->rollback();
            throw new \Exception($e->getMessage(),$e->getCode(),$e);
        }
        if ($trace) trace_exit();
    }

    /**
     * createUniqueUsername ($requested_username, $email = null)
     * @param string $requested_username
     * @param string $email
     * @return string $username
     */
    public function createUniqueUsername ($requested_username, $email = null) {
        global $trace, $options;
        if ($trace) trace_entry();
        if (!isset($requested_username) || $requested_username === '') {
            if (isset($email) && $email) {
                $matches = array();
                if (preg_match('/^([^@]+)@([^@]+)$/', $email, $matches)) {
                    $requested_username = $matches[1];
                }
                else {
                    $requested_username = $email;  # not sure what kind of email this is, by ok ...
                }
            }
            else {
                $requested_username = '';
            }
        }
        $username_stem = preg_replace('/[^a-z0-9]+/','_',strtolower($requested_username));
        $username_stem = preg_replace('/^[^a-z]+/','',$username_stem);
        $username_stem = preg_replace('/_+$/','',$username_stem);
        $len = strlen($username_stem);
        if ($len == 0) {
            $username_stem = 'user';
            $len = 4;
        }
        if ($len < 6) {  # we need to add some digits
            $hex_hash          = hash('md5', $requested_username);
            $num_digits_needed = 6 - $len;
            $extra_digits      = hexdec(substr($hex_hash,-$num_digits_needed,$num_digits_needed));
            $make_unique_with_digits = true;
            $counter           = substr($extra_digits,-$num_digit_needed,$num_digit_needed);
            $username_suffix   = $counter;
        }
        elseif (preg_match('/^.*[^0-9][0-9]+$/', $username_stem)) {
            $make_unique_with_digits = false;
            $counter           = 1;
            $username_suffix   = '';
        }
        else {
            $make_unique_with_digits = true;
            $counter           = 1;
            $username_suffix   = '';
        }
        $authdb = $this->context->authdb();
        while (true) {
            $proposed_username = $username_stem . $username_suffix;
            $count = $authdb->get('select count(*) from {auth_schema_}auth_user where username = ?', array($proposed_username));
            if ($count == 0) { break; }
            $counter++;
            $username_suffix = $make_unique_with_digits ? $counter : $this->_base26($counter);
        }
        $username = $proposed_username;
        if ($trace) trace_exit($username);
        return($username);
    }

    private function _base26 ($base10) {
        global $trace, $options;
        if ($trace) trace_entry();
        $base26 = '';
        $zero_ascii_char = ord('a');   # the base26 "digits" are the lower-case ascii alphabetic characters
        if ($base10 < 0) throw new \Exception("_base26($base10): argument cannot be less than zero");
        while ($base10 >= 26) {
            $base26_digit = $base10 % 26;
            $base10       = intval(($base10 - $base26_digit)/26);
            $base26       = chr($zero_ascii_char + $base26_digit) . $base26;
        }
        $base26 = chr($zero_ascii_char + $base10) . $base26;
        if ($trace) trace_exit($base26);
        return($base26);
    }

    /**
     * createUniqueEmail ($username)
     * @param string $username
     * @return string $email
     */
    public function createUniqueEmail ($username) {
        $email = $username . '@nowhere.com';
        return($email);
    }

    /**
     * createPasswordHash ($algorithm, $algorithm_option, $password, $salt)
     * @param string $algorithm
     * @param string $algorithm_option
     * @param string $password
     * @param string $salt
     * @return string $password_hash
     */
    public function createPasswordHash ($algorithm, $algorithm_option, $password, $salt) {
        global $trace;
        if ($trace) trace_entry();
        require_once('misc/pbkdf2.php');          # Get this from https://defuse.ca/php-pbkdf2.htm
        $password_hash = '';
        if (strcmp($algorithm,'SHA256xN') == 0) {
            $password_hash = pbkdf2('sha256', $password, $salt, $algorithm_option, 32);
        }
        else throw new \Exception("Unrecognized Authentication Algorithm while Hashing Password [$algorithm]",ERROR_AUTH_BADALGORITHM);
        if ($trace) trace_exit($password_hash);
        return($password_hash);
    }

    /**
     * createAuthToken ($algorithm, $algorithm_option, $password_hash, $session_expire_dttm, $client_connection_token)
     * @param string $algorithm
     * @param string $algorithm_option
     * @param string $password_hash
     * @param string $session_expire_dttm
     * @param string $client_connection_token
     * @return string $auth_token
     */
    public function createAuthToken ($algorithm, $algorithm_option, $password_hash, $session_expire_dttm, $client_connection_token = null) {
        global $trace;
        if ($trace) trace_entry();
        $test_mode = $this->context->getOption('test_mode');
        if (!isset($client_connection_token)) $client_connection_token = $this->getClientConnectionToken();

        if (strcmp($algorithm,'SHA256xN') == 0) $auth_token = hash('sha256', "$password_hash|$session_expire_dttm|$client_connection_token");
        else                          throw new \Exception("Unrecognized Authentication Algorithm ($algorithm) while Creating Authorization Token",ERROR_AUTH_BADALGORITHM);

        if ($trace) trace_exit($auth_token);
        return($auth_token);
    }

    /**
     * getAuthenticatedUser ($user_token, $auth_token = null, $session_expire_dttm = APP_END_OF_TIME, $client_connection_token = null)
     * @param string $user_token
     * @param string $auth_token = null
     * @param string $session_expire_dttm = APP_END_OF_TIME
     * @param string $client_connection_token = null
     * @return array $user
     */
    public function getAuthenticatedUser ($user_token, $auth_token = null, $session_expire_dttm = APP_END_OF_TIME, $client_connection_token = null) {
        global $trace;
        if ($trace) trace_entry();
        $authdb               = $this->context->authdb();
        $error_code           = 0;
        $error_message        = '';
        $requested_user_token = $user_token;
        $current_time         = time();
        $current_dttm         = date('Y-m-d H:i:s', $current_time);

        $email                = null;
        $username             = null;
        $cell_phone           = null;
        $user_token_column    = 'email';

        if     (preg_match('/@/', $user_token))        { $email      = $user_token; $user_token_column = 'email'; }
        elseif (preg_match('/^[0-9]+$/', $user_token)) { $cell_phone = $user_token; $user_token_column = 'cell_phone'; }
        else                                           { $username   = $user_token; $user_token_column = 'username'; }

        if (!$auth_token) {
            $username       = APP_GUEST_USERNAME;
            $error_code     = ERROR_AUTH_NOAUTHTOKEN;
            $error_message  = 'No Authorization Token';
            throw new \Exception($error_message, $error_code);
        }
        elseif (strcmp($current_dttm,$session_expire_dttm) >= 0) {
            $username       = APP_GUEST_USERNAME;
            $error_code     = ERROR_AUTH_SESSIONEXPIRED;
            $error_message  = 'Session expired';
            throw new \Exception($error_message, $error_code);
        }

        $user_sql = <<<EOF
select
    u.user_id,
    u.email,
    u.username,
    u.cell_phone,
    u.salt,
    u.password_hash,
    u.algorithm,
    u.algorithm_option,
    u.lang,
    u.locale,
    u.timezone,
    u.theme,
    u.nick_name,
    u.first_name,
    u.middle_name,
    u.last_name,
    u.address1,
    u.city,
    u.state_code,
    u.postal_code,
    u.country_code,
    u.last_login_dttm,
    u.default_group_id,
    u.default_app_id,
    u.status,
    u.claimed_dttm
from {auth_schema_}auth_user u
where u.$user_token_column = ?
EOF;
        $user = $authdb->get_hash($user_sql, array($user_token));
        if (isset($user)) {
            $user_id              = $user['user_id'];
            $algorithm            = $user['algorithm'];
            $algorithm_option     = $user['algorithm_option'];
            $password_hash        = $user['password_hash'];
            $status               = $user['status'];
            $claimed_dttm         = $user['claimed_dttm'];
            unset($user['password_hash']);
            unset($user['status']);
            unset($user['claimed_dttm']);
            $user['user_token']   = $user_token;

            if ($user_id == APP_GUEST_USER_ID) {
                $server_auth_token = APP_DUMMY_AUTH_TOKEN;
            }
            else {
                if ($status === 'B') {
                    $error_code    = ERROR_AUTH_USERBLOCKED;
                    $error_message = "User is blocked and is not allowed to log in [$user_token]";
                    $user          = null;
                }
                elseif ($status === 'C') {
                    $claimed_time = strtotime($claimed_dttm);
                    $remaining_minutes = intval(($claimed_time + 3600 - $current_time)/60);
                    if ($remaining_minutes > 0) {
                        $error_message = "User has not completed the registration process [$user_token]. The account must be confirmed within $remaining_minutes minutes using the confirmation code sent by e-mail.";
                    }
                    else {
                        $error_message = "User has not completed the registration process [$user_token]. Please begin the registration process again.";
                    }
                    $error_code    = ERROR_AUTH_USERNOTCONFIRMED;
                    $user          = null;
                }
                elseif ($status !== 'A') {
                    $error_code    = ERROR_AUTH_USERNOTFOUND;
                    $error_message = "User Not Found [$user_token]. Please register first.";
                    $user          = null;
                }
                else {
                    if (!isset($client_connection_token)) $client_connection_token = $this->getClientConnectionToken();
                    if ($this->createAuthToken($algorithm, $algorithm_option, $password_hash, $session_expire_dttm, $client_connection_token) !== $auth_token) {
                        $error_code    = ERROR_AUTH_BAD_PASSWORD;
                        $error_message = "User or Password Incorrect [$user_token]";
                        $user          = null;
                    }
                }
            }
        }
        else {
            $error_code    = ERROR_AUTH_USERNOTFOUND;
            $error_message = "User Not Found [$user_token]. Please register first.";
        }
        if (!isset($user)) {
            $user = array('user_id'=>APP_GUEST_USER_ID, 'username'=>APP_GUEST_USERNAME, 'locale'=>'en', 'timezone'=>'America/New_York', 'theme'=>'default');
            $user['user_token'] = APP_GUEST_USERNAME;
        }
        if ($error_message) {
            $user['error_code']           = $error_code;
            $user['error_message']        = $error_message;
            $user['requested_user_token'] = $requested_user_token;
        }
        if ($trace) trace_exit($user);
        return($user);
    }
}

